Skip to content
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