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 support for intersection types/interfaces #915

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -1655,7 +1655,7 @@ exports[`codeTypeHandler > stateless TS component and variable type takes preced
"description": "",
"required": true,
"tsType": {
"name": "string",
"name": "number | string",
},
},
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ describe('codeTypeHandler', () => {
expect(documentation.descriptors).toMatchSnapshot();
});

test('does not support union proptypes', () => {
test('does support union proptypes', () => {
const definition = parse
.statement(
`(props: Props) => <div />;
Expand All @@ -274,7 +274,24 @@ describe('codeTypeHandler', () => {
.get('expression') as NodePath<ArrowFunctionExpression>;

expect(() => codeTypeHandler(documentation, definition)).not.toThrow();
expect(documentation.descriptors).toEqual({});
expect(documentation.descriptors).toEqual({
bar: {
required: true,
flowType: {
name: 'literal',
value: "'barValue'",
},
description: '',
},
foo: {
required: true,
flowType: {
name: 'literal',
value: "'fooValue'",
},
description: '',
},
});
});

describe('imported prop types', () => {
Expand Down
22 changes: 19 additions & 3 deletions packages/react-docgen/src/handlers/codeTypeHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { NodePath } from '@babel/traverse';
import type { FlowType } from '@babel/types';
import type { ComponentNode } from '../resolver/index.js';
import type { Handler } from './index.js';
import mergeTSIntersectionTypes from '../utils/mergeTSIntersectionTypes.js';

function setPropDescriptor(
documentation: Documentation,
Expand Down Expand Up @@ -80,15 +81,30 @@ function setPropDescriptor(
return;
}
const type = getTSType(typeAnnotation, typeParams);

const propName = getPropertyName(path);

if (!propName) return;

const propDescriptor = documentation.getPropDescriptor(propName);

propDescriptor.required = !path.node.optional;
propDescriptor.tsType = type;
if (propDescriptor.tsType) {
const mergedType = mergeTSIntersectionTypes(
{
name: propDescriptor.tsType.name,
required: propDescriptor.required,
},
{
name: type.name,
required: !path.node.optional,
},
);

propDescriptor.tsType.name = mergedType.name;
propDescriptor.required = mergedType.required;
} else {
propDescriptor.tsType = type;
propDescriptor.required = !path.node.optional;
}

// We are doing this here instead of in a different handler
// to not need to duplicate the logic for checking for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,36 @@ exports[`getTSType > can resolve indexed access to imported type 1`] = `
}
`;

exports[`getTSType > deep resolve intersection types 1`] = `
{
"elements": [
{
"key": "name",
"value": {
"name": "string",
"required": true,
},
},
{
"key": "a",
"value": {
"name": "number",
"required": true,
},
},
{
"key": "b",
"value": {
"name": "string",
"required": false,
},
},
],
"name": "intersection",
"raw": "{ name: string } & (MyType | MySecondType)",
}
`;

exports[`getTSType > detects array type 1`] = `
{
"elements": [
Expand Down
20 changes: 20 additions & 0 deletions packages/react-docgen/src/utils/__tests__/getTSType-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ const mockImporter = makeMockImporter({
true,
).get('declaration') as NodePath<Declaration>,

MySecondType: (stmtLast) =>
stmtLast<ExportNamedDeclaration>(
`export type MySecondType = { a: number, b?: never };`,
true,
).get('declaration') as NodePath<Declaration>,

MyGenericType: (stmtLast) =>
stmtLast<ExportNamedDeclaration>(
`export type MyGenericType<T> = { a: T, b: Array<T> };`,
Expand Down Expand Up @@ -501,6 +507,20 @@ describe('getTSType', () => {
expect(getTSType(typePath)).toMatchSnapshot();
});

test('deep resolve intersection types', () => {
const typePath = typeAlias(
`
const x: SuperType = {};
import { MyType } from 'MyType';
import { MySecondType } from 'MySecondType';
type SuperType = { name: string } & (MyType | MySecondType);
`,
mockImporter,
);

expect(getTSType(typePath)).toMatchSnapshot();
});

test('resolves typeof of import type', () => {
const typePath = typeAlias(
"var x: typeof import('MyType') = {};",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, test } from 'vitest';
import mergeTSIntersectionTypes from '../mergeTSIntersectionTypes.js';

describe('mergeTSIntersectionTypes', () => {
test('it merges two types correctly', () => {
const mergedType = mergeTSIntersectionTypes(
{
name: 'string',
required: true,
},
{
name: 'number',
required: true,
},
);

expect(mergedType).toEqual({
name: 'string | number',
required: true,
});
});

test('it ignores types of "never"', () => {
const mergedType = mergeTSIntersectionTypes(
{
name: 'string',
required: true,
},
{
name: 'never',
required: true,
},
);

expect(mergedType).toEqual({
name: 'string',
required: true,
});
});

test('if one of the types is "unknown", it overrides all other types', () => {
const mergedType = mergeTSIntersectionTypes(
{
name: 'string',
required: true,
},
{
name: 'unknown',
required: true,
},
);

expect(mergedType).toEqual({
name: 'unknown',
required: true,
});
});

test('if one of the types is NOT required, the merged one is NOT required too', () => {
const mergedType = mergeTSIntersectionTypes(
{
name: 'string',
required: true,
},
{
name: 'number',
required: false,
},
);

expect(mergedType).toEqual({
name: 'string | number',
required: false,
});
});
});
102 changes: 97 additions & 5 deletions packages/react-docgen/src/utils/getTSType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ import type {
TypeScript,
TSQualifiedName,
TSLiteralType,
TSParenthesizedType,
} from '@babel/types';
import { getDocblock } from './docblock.js';
import mergeTSIntersectionTypes from './mergeTSIntersectionTypes.js';

const tsTypes: Record<string, string> = {
TSAnyKeyword: 'any',
Expand Down Expand Up @@ -69,6 +71,7 @@ const namedTypes: Record<
TSUnionType: handleTSUnionType,
TSFunctionType: handleTSFunctionType,
TSIntersectionType: handleTSIntersectionType,
TSParenthesizedType: handleTSParenthesizedType,
TSMappedType: handleTSMappedType,
TSTupleType: handleTSTupleType,
TSTypeQuery: handleTSTypeQuery,
Expand Down Expand Up @@ -127,8 +130,7 @@ function handleTSTypeReference(
}

const resolvedPath =
(typeParams && typeParams[type.name]) ||
resolveToValue(path.get('typeName'));
(typeParams && typeParams[type.name]) || resolveToValue(typeName);

const typeParameters = path.get('typeParameters');
const resolvedTypeParameters = resolvedPath.get('typeParameters') as NodePath<
Expand Down Expand Up @@ -267,19 +269,109 @@ function handleTSUnionType(
};
}

function handleTSParenthesizedType(
path: NodePath<TSParenthesizedType>,
typeParams: TypeParameters | null,
): ElementsType<TSFunctionSignatureType> {
const innerTypePath = path.get('typeAnnotation');
const resolvedType = getTSTypeWithResolvedTypes(innerTypePath, typeParams);

return {
name: 'parenthesized',
raw: printValue(path),
elements: Array.isArray(resolvedType) ? resolvedType : [resolvedType],
};
}

interface PropertyWithKey {
key: TypeDescriptor<TSFunctionSignatureType> | string;
value: TypeDescriptor<TSFunctionSignatureType>;
description?: string | undefined;
}

function handleTSIntersectionType(
path: NodePath<TSIntersectionType>,
typeParams: TypeParameters | null,
): ElementsType<TSFunctionSignatureType> {
const resolvedTypes = path
.get('types')
.map((subTypePath) => getTSTypeWithResolvedTypes(subTypePath, typeParams));

let elements: Array<TypeDescriptor<TSFunctionSignatureType>> = [];

resolvedTypes.forEach((resolvedType) => {
switch (resolvedType.name) {
default:
case 'signature':
elements.push(resolvedType);
break;
case 'parenthesized': {
if ('elements' in resolvedType && resolvedType.elements[0]) {
const firstElement = resolvedType.elements[0];

if (firstElement && 'elements' in firstElement) {
elements = [...elements, ...firstElement.elements];
}
}
break;
}
}
});

const elementsDedup: PropertyWithKey[] = [];

// dedup elements
elements.forEach((element) => {
if (hasSignature(element)) {
const { signature } = element;

if (hasProperties(signature)) {
signature.properties.forEach((property) => {
const existingIndex = elementsDedup.findIndex(
({ key }) => key === property.key,
);

if (existingIndex === -1) {
elementsDedup.push(property);
} else {
const existingProperty = elementsDedup[existingIndex];

if (existingProperty) {
elementsDedup[existingIndex] = {
key: property.key,
value: mergeTSIntersectionTypes(
existingProperty.value,
property.value,
),
};
}
}
});
}
} else {
elementsDedup.push(element as unknown as PropertyWithKey);
}
});

return {
name: 'intersection',
raw: printValue(path),
elements: path
.get('types')
.map((subType) => getTSTypeWithResolvedTypes(subType, typeParams)),
elements: elementsDedup as unknown as Array<
TypeDescriptor<TSFunctionSignatureType>
>,
};
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function hasSignature(element: any): element is { signature: unknown } {
return 'signature' in element;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function hasProperties(element: any): element is { properties: unknown } {
return 'properties' in element;
}

// type OptionsFlags<Type> = { [Property in keyof Type]; };
function handleTSMappedType(
path: NodePath<TSMappedType>,
Expand Down
Loading