/**
* Property extraction for TypeScript type aliases.
*
* `extractTypeAliasProperties` walks the resolved type and emits members for
* named properties, index signatures, call signatures, and construct
* signatures. Handles object literals, intersections, mapped types
* (Partial, Pick, Readonly, etc.), type references, and function types.
* Called by `extractTypeInfo` in `typescript-extract-type.ts` for type
* aliases (interfaces use a different path that walks `node.members`
* directly).
*
* @see `typescript-extract-shared.ts` for shared helpers
* @see `typescript-extract-type.ts` for the dispatcher
*
* @module
*/
import ts from 'typescript';
import type {MemberKind, DeclarationModifier} from './types.js';
import type {DeclarationJsonBuild, MemberJsonBuild} from './declaration-build.js';
import {type Diagnostic} from './diagnostics.js';
import {parseComment, applyToDeclaration, type TsdocParsedComment} from './tsdoc.js';
import {type IsExternalFile} from './typescript-program.js';
import {
emitCallOrConstructSignature,
filterExternalProperties,
getNodeLocation,
isExternalIntersectionBranch,
populateCallableMember,
resolveIntersectionTypeNode,
} from './typescript-extract-shared.js';
/**
* Check whether a resolved type has properties worth extracting for documentation.
*
* Returns `true` for object-like types (object literals, intersections, mapped types,
* type references, function types). Returns `false` for types where `getProperties()`
* would return prototype methods or ambiguous results (unions, primitives, tuples,
* generic type references like `Array<T>`).
*/
const hasExtractableProperties = (type: ts.Type): boolean => {
// Intersections: checker merges properties from all branches
if (type.isIntersection()) return true;
// Unions: ambiguous property set (different branches have different shapes)
if (type.isUnion()) return false;
// Must be an object type
if (!(type.flags & ts.TypeFlags.Object)) return false;
const objFlags = (type as ts.ObjectType).objectFlags;
// Tuples give array prototype methods — not useful
if (objFlags & ts.ObjectFlags.Tuple) return false;
// Generic type references (Array<T>, Promise<T>, Set<T>) give prototype methods.
// Mapped types can also have Reference when instantiated (Partial<T>, Pick<T,K>),
// so allow Reference when Mapped is also set.
if (objFlags & ts.ObjectFlags.Reference && !(objFlags & ts.ObjectFlags.Mapped)) return false;
return true;
};
/**
* Get the index type for a type, filtering out external branches in intersections.
*
* For non-intersection types, this delegates to `checker.getIndexTypeOfType`.
* For intersections, it walks each branch and returns the index type from the
* first local branch (skipping branches whose declarations are all in external
* files). Without this filtering, `getIndexTypeOfType` on the merged type would
* surface index signatures contributed only by external branches like
* `HTMLAttributes<HTMLDivElement>`, which is wrong for a library's own type.
*
* The "first local branch" simplification is conservative: multiple local
* branches contributing index signatures of the same kind would normally be
* intersected by the checker, but the merged result can also pull in external
* contributions through inheritance — so we prefer the simpler local path. This
* case is exceedingly rare in practice.
*/
const extractLocalIndexType = (
nodeType: ts.Type,
typeNode: ts.Node,
checker: ts.TypeChecker,
isExternalFile: IsExternalFile,
indexKind: ts.IndexKind,
): ts.Type | undefined => {
if (!nodeType.isIntersection()) {
return checker.getIndexTypeOfType(nodeType, indexKind);
}
const intersectionNode = resolveIntersectionTypeNode(nodeType, typeNode);
if (!intersectionNode) {
// Cannot determine branches — fall back to merged type. Conservative:
// preserves prior behavior for the rare synthesized-intersection case.
return checker.getIndexTypeOfType(nodeType, indexKind);
}
for (const branch of intersectionNode.types) {
if (isExternalIntersectionBranch(branch, checker, isExternalFile)) continue;
const branchType = checker.getTypeAtLocation(branch);
const branchIndex = checker.getIndexTypeOfType(branchType, indexKind);
if (branchIndex) return branchIndex;
}
return undefined;
};
/**
* Resolve, emit, and diagnose a local index signature for a type alias.
*
* Wraps `extractLocalIndexType` with the boilerplate shared by string and
* number kinds: push a `[key: string]` / `[key: number]` member when found,
* flip `partial: true` and add a `type_extraction_failed` diagnostic on
* checker errors. Pulled out of the call site to avoid copy-paste drift
* between the two kinds.
*
* @mutates declaration - appends a member when an index sig is present
* @mutates diagnostics - adds a `type_extraction_failed` diagnostic on checker error
*/
const emitLocalIndexSignature = (
declaration: DeclarationJsonBuild,
nodeType: ts.Type,
node: ts.TypeAliasDeclaration,
checker: ts.TypeChecker,
diagnostics: Array<Diagnostic>,
isExternalFile: IsExternalFile,
kind: 'string' | 'number',
): void => {
const indexKind = kind === 'string' ? ts.IndexKind.String : ts.IndexKind.Number;
try {
const indexType = extractLocalIndexType(
nodeType,
node.type,
checker,
isExternalFile,
indexKind,
);
if (indexType) {
(declaration.members ??= []).push({
name: `[key: ${kind}]`,
kind: 'variable',
typeSignature: checker.typeToString(indexType),
});
}
} catch (err) {
declaration.partial = true;
const loc = getNodeLocation(node);
diagnostics.push({
kind: 'type_extraction_failed',
file: loc.file,
line: loc.line,
column: loc.column,
message: `Failed to extract ${kind} index signature for type "${declaration.name ?? '<default export>'}": ${err instanceof Error ? err.message : String(err)}`,
severity: 'warning',
symbolName: declaration.name ?? '<default export>',
});
}
};
/**
* Detect whether a property symbol is readonly.
*
* Two-layer detection:
* 1. Check property declarations for `readonly` modifier (works for object literals,
* intersections, type references)
* 2. For mapped types with a `readonly` token (e.g., `Readonly<T>`,
* `{ readonly [K in ...]: ... }`), all properties are readonly regardless
* of the original declaration
*/
const isReadonlyProperty = (prop: ts.Symbol, mappedReadonly: boolean): boolean => {
const decls = prop.getDeclarations();
if (decls) {
for (const decl of decls) {
if (ts.canHaveModifiers(decl)) {
const mods = ts.getModifiers(decl);
if (mods?.some((m) => m.kind === ts.SyntaxKind.ReadonlyKeyword)) return true;
}
}
}
return mappedReadonly;
};
/**
* Extract properties from a type alias via the TypeScript checker API.
*
* Handles object literals, intersections, mapped types (Partial, Pick, Readonly, etc.),
* type references, and function types. Extracts:
* - Named properties (with readonly/optional detection, TSDoc from declarations)
* - Index signatures (string/number)
* - Call signatures (`(call)`)
* - Construct signatures (`(construct)`)
*
* @mutates declaration - adds members
*/
export const extractTypeAliasProperties = (
node: ts.TypeAliasDeclaration,
nodeType: ts.Type,
checker: ts.TypeChecker,
declaration: DeclarationJsonBuild,
diagnostics: Array<Diagnostic>,
isExternalFile: IsExternalFile,
): void => {
if (!hasExtractableProperties(nodeType)) return;
// Drop properties contributed by external types (node_modules / declaration
// files) and surface those external types in the `intersects` field. Applies
// to the property-bearing shapes that pass `hasExtractableProperties` above —
// intersections, bare references, indexed-access. Unions are gated out here
// (the Svelte prop path calls `filterExternalProperties` directly, so unions
// still surface `intersects` there, just not for plain type aliases).
const {properties: filteredProperties, externalTypes} = filterExternalProperties(
nodeType,
node.type,
checker,
isExternalFile,
);
if (externalTypes.length) {
declaration.intersects = externalTypes;
}
// Detect mapped-type-level readonly (e.g., Readonly<T>, { readonly [K in ...]: ... })
let mappedReadonly = false;
if (
nodeType.flags & ts.TypeFlags.Object &&
(nodeType as ts.ObjectType).objectFlags & ts.ObjectFlags.Mapped
) {
// ts.MappedType is not in the public API, but the `declaration` property
// exists at runtime on mapped types and holds the MappedTypeNode AST node
const mappedDecl = (nodeType as ts.ObjectType & {declaration?: ts.MappedTypeNode}).declaration;
if (mappedDecl?.readonlyToken) {
mappedReadonly = true;
}
}
// Extract named properties (external contributions already filtered out)
for (const prop of filteredProperties) {
// Skip internal TypeScript symbols
if (prop.getName().startsWith('__@')) continue;
const optional = (prop.flags & ts.SymbolFlags.Optional) !== 0;
const readonly = isReadonlyProperty(prop, mappedReadonly);
// Determine kind: function (has call signatures) vs variable (property)
let propType: ts.Type;
try {
propType = checker.getTypeOfSymbolAtLocation(prop, node);
} catch {
continue;
}
const callSigs = propType.getCallSignatures();
const kind: MemberKind = callSigs.length > 0 ? 'function' : 'variable';
const member: MemberJsonBuild = {
name: prop.getName(),
kind,
};
if (optional) member.optional = true;
// Modifiers
const modifiers: Array<DeclarationModifier> = [];
if (readonly) modifiers.push('readonly');
if (modifiers.length > 0) member.modifiers = modifiers;
// Extract TSDoc from the property's declaration if available
const decls = prop.getDeclarations();
let propTsdoc: TsdocParsedComment | undefined = undefined;
if (decls && decls.length > 0) {
propTsdoc = parseComment(decls[0]!, decls[0]!.getSourceFile());
applyToDeclaration(member, propTsdoc);
}
// Type signature and function-specific fields
if (kind === 'function' && callSigs.length > 0) {
populateCallableMember(
member,
callSigs,
checker,
propTsdoc,
decls?.[0] ?? node,
prop.getName(),
diagnostics,
);
} else {
member.typeSignature = checker.typeToString(propType);
if (optional) {
// Strip trailing " | undefined" that the checker adds for optional props
member.typeSignature = checker.typeToString(checker.getNonNullableType(propType));
}
}
(declaration.members ??= []).push(member);
}
// Extract index signatures. For intersections, only emit signatures contributed
// by local branches — external branches like `HTMLAttributes<HTMLDivElement>`
// otherwise leak their string index signature onto the consuming type.
emitLocalIndexSignature(
declaration,
nodeType,
node,
checker,
diagnostics,
isExternalFile,
'string',
);
emitLocalIndexSignature(
declaration,
nodeType,
node,
checker,
diagnostics,
isExternalFile,
'number',
);
// Extract call and construct signatures. TSDoc resolves through the
// signature's own declaration — for type aliases, that's typically the
// inline call/construct signature node the user wrote.
const errorContext = {node, kindLabel: 'type'};
emitCallOrConstructSignature(
() => nodeType.getCallSignatures(),
'call',
(sig) => sig.getDeclaration(),
node,
declaration,
checker,
diagnostics,
errorContext,
);
emitCallOrConstructSignature(
() => nodeType.getConstructSignatures(),
'construct',
(sig) => sig.getDeclaration(),
node,
declaration,
checker,
diagnostics,
errorContext,
);
};
{
"path": "typescript-extract-type-properties.ts",
"declarations": [
{
"name": "extractTypeAliasProperties",
"kind": "function",
"docComment": "Extract properties from a type alias via the TypeScript checker API.\n\nHandles object literals, intersections, mapped types (Partial, Pick, Readonly, etc.),\ntype references, and function types. Extracts:\n- Named properties (with readonly/optional detection, TSDoc from declarations)\n- Index signatures (string/number)\n- Call signatures (`(call)`)\n- Construct signatures (`(construct)`)",
"typeSignature": "(node: TypeAliasDeclaration, nodeType: Type, checker: TypeChecker, declaration: DeclarationJsonBuild, diagnostics: ({ ...; } | ... 12 more ... | { ...; })[], isExternalFile: IsExternalFile): void",
"sourceLine": 195,
"mutates": {
"declaration": "adds members"
},
"parameters": [
{
"name": "node",
"type": "TypeAliasDeclaration"
},
{
"name": "nodeType",
"type": "Type"
},
{
"name": "checker",
"type": "TypeChecker"
},
{
"name": "declaration",
"type": "DeclarationJsonBuild"
},
{
"name": "diagnostics",
"type": "({ symbolName: string; file: string; message: string; severity: \"error\" | \"warning\"; kind: \"type_extraction_failed\"; line?: number | undefined; column?: number | undefined; } | { functionName: string; ... 5 more ...; column?: number | undefined; } | ... 11 more ... | { ...; })[]"
},
{
"name": "isExternalFile",
"type": "IsExternalFile"
}
],
"returnType": "void"
}
],
"moduleComment": "Property extraction for TypeScript type aliases.\n\n`extractTypeAliasProperties` walks the resolved type and emits members for\nnamed properties, index signatures, call signatures, and construct\nsignatures. Handles object literals, intersections, mapped types\n(Partial, Pick, Readonly, etc.), type references, and function types.\nCalled by `extractTypeInfo` in `typescript-extract-type.ts` for type\naliases (interfaces use a different path that walks `node.members`\ndirectly).\n\n@see `typescript-extract-shared.ts` for shared helpers\n@see `typescript-extract-type.ts` for the dispatcher",
"dependencies": [
"declaration-build.ts",
"diagnostics.ts",
"tsdoc.ts",
"types.ts",
"typescript-extract-shared.ts",
"typescript-program.ts"
],
"dependents": [
"typescript-extract-type.ts"
]
}