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 13 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -1655,7 +1655,7 @@ exports[`codeTypeHandler > stateless TS component and variable type takes preced
"description": "",
"required": true,
"tsType": {
"name": "string",
"name": "number | string",
},
},
}
Original file line number Diff line number Diff line change
@@ -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 />;
@@ -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', () => {
22 changes: 19 additions & 3 deletions packages/react-docgen/src/handlers/codeTypeHandler.ts
Original file line number Diff line number Diff line change
@@ -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,
@@ -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
Original file line number Diff line number Diff line change
@@ -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": [
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
@@ -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> };`,
@@ -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') = {};",
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
@@ -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',
@@ -69,6 +71,7 @@ const namedTypes: Record<
TSUnionType: handleTSUnionType,
TSFunctionType: handleTSFunctionType,
TSIntersectionType: handleTSIntersectionType,
TSParenthesizedType: handleTSParenthesizedType,
TSMappedType: handleTSMappedType,
TSTupleType: handleTSTupleType,
TSTypeQuery: handleTSTypeQuery,
@@ -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<
@@ -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>,
25 changes: 22 additions & 3 deletions packages/react-docgen/src/utils/getTypeFromReactComponent.ts
Original file line number Diff line number Diff line change
@@ -183,9 +183,28 @@ export function applyToTypeProperties(
(typesPath) =>
applyToTypeProperties(documentation, typesPath, callback, typeParams),
);
} else if (!path.isUnionTypeAnnotation()) {
// The react-docgen output format does not currently allow
// for the expression of union types
} else if (path.isParenthesizedExpression() || path.isTSParenthesizedType()) {
const typeAnnotation = path.get('typeAnnotation');
const typeAnnotationPath = Array.isArray(typeAnnotation)
? typeAnnotation[0]
: typeAnnotation;

if (typeAnnotationPath) {
applyToTypeProperties(
documentation,
typeAnnotationPath,
callback,
typeParams,
);
}
} else if (path.isUnionTypeAnnotation() || path.isTSUnionType()) {
const typeNodes = path.get('types');
const types = Array.isArray(typeNodes) ? typeNodes : [typeNodes];

types.forEach((typesPath) =>
applyToTypeProperties(documentation, typesPath, callback, typeParams),
);
} else {
const typePath = resolveGenericTypeAnnotation(path);

if (typePath) {
60 changes: 60 additions & 0 deletions packages/react-docgen/src/utils/mergeTSIntersectionTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type {
TSFunctionSignatureType,
TypeDescriptor,
} from '../Documentation.js';

/**
* Merges two TSFunctionSignatureType types into one.
*
* @example
* const existingType = {
* "key": "children",
* "value": {
* "name": "ReactNode",
* "required": true,
* },
* };
* const newType = {
* "key": "children",
* "value": {
* "name": "never",
* "required": false,
* },
* };
*
* return {
* "key": "children",
* "value": {
* "name": "ReactNode",
* "required": false,
* },
* };
*/
export default (
existingType: TypeDescriptor<TSFunctionSignatureType>,
newType: TypeDescriptor<TSFunctionSignatureType>,
): TypeDescriptor<TSFunctionSignatureType> => {
const required =
newType.required === false || existingType.required === false
? false
: existingType.required;

const existingTypesArray = existingType.name.split('|').map((t) => t.trim());
const existingTypes = new Set(existingTypesArray);

if (!['never'].includes(newType.name)) {
existingTypes.add(newType.name);
}

if (existingType.name === 'unknown' || newType.name === 'unknown') {
return {
name: 'unknown',
required,
};
}

return {
name: Array.from(existingTypes).join(' | '),
required,
};
};