Skip to content

Pattern matching ​

A pattern is the code you want the file to look like, written as a normal TypeScript snippet with $-prefixed placeholders. A file matches if its top-level declaration surface conforms to the template.

ts
pattern: `
  interface IProps {}

  const $Name: React.FC<IProps> = () => {}

  export default $Name
`;
ts
pattern: `
  @Controller('$name')
  export class ${Name}Controller {}
`;
ts
pattern: `
  export const $name = async (params: $NameParams): Promise<$NameResponse> => {}
`;
ts
pattern: `
  export interface ${Name}Model {
    id: string;
  }
  export type ${Name}CreateInput = Omit<${Name}Model, 'id'>;
`;

The governing principle ​

The template is a lower bound. Anything the template states must hold. Anything the template does not state is unconstrained.

That single rule explains everything below.

What is compared ​

AspectCompared?
Declaration kind (interface vs type vs const vs function vs class)✅ strictly
Declaration name✅ literal, or bound via a placeholder
Export form (default vs named)✅ exact, see below
Type annotation the template states✅ compared textually
const / let / var keyword✅
Variable initializer kind the template states (e.g. = () => {} → an arrow)✅

What is ignored: by design ​

AspectIgnored?
Function / method bodies✅ never looked at by a pattern, see note below
Declaration order✅ interface-first or component-first both pass
Interface / class members✅ interface IProps {} means "exists", not "empty"
Extra declarations in the file✅ private helpers, sub-components, constants are fine
Type annotations the template omits✅ unconstrained
Parameters the template omits (() => {})✅ "don't care"
Comments, blank lines, semicolons, quotes✅
import statements✅ not part of a pattern

So this file passes the pattern above, despite the extra helper, a populated interface, reordered declarations, and a full body:

Bodies are opaque to a pattern, but not off-limits to Shapelint. Two rule options reach inside them: usage rules ban specific elements/calls, and bodyOrder fixes the order of statements (e.g. useRef → useState → useEffect → return).

tsx
const styles = 'px-2'; // extra private declaration, fine

const Button: React.FC<IProps> = ({ label }) => {
  const [open, setOpen] = useState(false); // body, never inspected
  return <button>{label}</button>;
};

interface IProps {
  label: string;
} // out of order, has members, fine

export default Button;

Export form is exact ​

The export surface is the one thing the template pins down precisely, because it is the architectural boundary that matters, it's what other modules can reach. The recognized forms:

FormExample
noneconst X = ... (not exported)
namedexport const X = ...
default-inlineexport default function X() {}
default-assignmentconst X = ... then export default X

export default X is folded back onto X's declaration, so const X = ...; export default X and the template describe the same thing.

Placeholders ​

A placeholder is any identifier starting with $. It binds to whatever identifier appears in that position, and the casing of the placeholder name is the casing constraint on what it binds:

PlaceholderBinds an identifier that isExample match
$NamePascalCaseButton, Card
$namecamelCasegetUser, total
$NAMECONSTANT_CASEGET, MAX_SIZE
$_anything (anonymous)any identifier

A placeholder is consistent: $Name bound once must be the same identifier everywhere it appears, in the template and in the filename. So filename: '$Name.tsx' on Button.tsx binds $Name = Button, and the pattern then requires the component to be named Button.

Placeholders are valid in identifier positions only, declaration names, type references (React.FC<$Props>), export clauses, and filename specs.

Affixes: brace-delimited holes with literal prefixes/suffixes ​

A bare $Name binds a whole identifier. To require a fixed prefix or suffix around the hole, brace-delimit it as ${Name} so Shapelint knows where the hole ends and the literal identifier text begins:

Pattern nameMatchesBindsRejects
${Name}ControllerUsersController$Name=UsersUsersThing
App${Name}ServiceAppCacheService$Name=CacheCacheService
use${Name}useCache$Name=Cache
${NAME}_TOKENAUTH_TOKEN$NAME=AUTHAUTH_KEY

The hole's case sigil still applies to the stripped core (${Name} → the core is PascalCase). A name that doesn't carry the required literal affix is reported as ARCH_NAME_AFFIX_MISMATCH. Brace holes with no affix (export type ${Name} = $_) are just an alternative spelling of $Name.

Correlated placeholders ​

One logical name often appears in different cases across a file and its name, a kebab-case filename, a PascalCase export, a CONSTANT_CASE export. $name, $Name and $NAME (plus the filename hole) are one logical word, automatically: Shapelint binds it once, transforms between cases, and requires every occurrence to spell the same word. Use different letters ($Name vs $Other) when you want genuinely independent holes.

ts
{
  files: '*/*.controller.ts',
  filename: '$name.controller.ts',              // binds the word "get-users"
  pattern: 'export class ${Name}Controller {}', // ⇒ must be "GetUsersController"
}

get-users.ts exporting class GetUsersController passes; class FetchUsersController fails with ARCH_NAME_CORRELATION_MISMATCH (and an autofix rename to GetUsersController). This also correlates several declarations within one file: a type, a schema const, and request classes that share the word:

ts
pattern: `
  export type ${Name} = $_
  export const ${Name}Schema = $_
  export class ${NAME}_REQUEST {}
  export class ${NAME}_RESPONSE {}
`;

Anchoring: which declaration the pattern targets ​

By default the pattern binds to the first top-level declaration of its kind. When a file declares local helper classes above the "real" one, that's wrong:

ts
class SUBSCRIBE_BODY extends makeBody(schema) {} // local helper
class UNSUBSCRIBE_BODY extends makeBody(schema) {} // local helper

@WebSocketGateway({ namespace: 'chat' })
export class ChatGateway {} // the class the rule means

Set target to target the declaration the pattern structurally describes:

  • 'decorated', the class carrying the decorator the pattern names. This is the default whenever the pattern's class has a decorator, so the rule @WebSocketGateway($_) export class ${Name}Gateway {} binds ChatGateway above with no exclude.
  • 'exported', the single exported declaration, ignoring non-exported helpers.
  • 'first', the legacy positional default.

A class that the pattern decorates must actually carry those decorators, else ARCH_MISSING_DECORATOR.

Member patterns (methods) ​

The strongest conventions are per-member. methods requires a shape for every member a decorator selector matches:

ts
{
  files: '*/*.controller.ts',
  methods: {
    where: '@(Get|Post|Put|Delete|Patch)', // decorator selector on the member
    require: ['@Auth', '@ApiResponse'],      // decorators that must co-occur
  },
}

A @Get() handler missing @ApiResponse is flagged ARCH_MEMBER_PATTERN, the defect being an unguarded or undocumented endpoint. where accepts @Name, an alternation @(A|B), or a @glob*; omit it to select every method. methods may also be an array of specs.

Requirement: templates must be valid TypeScript ​

Because a placeholder like $Name is itself a legal TypeScript identifier, every template is required to parse as valid TypeScript on its own. This is what lets Shapelint use one parser and no custom grammar. A template that fails to parse is reported as a config error when the config loads, not silently at check time.

Multiple acceptable shapes ​

A single pattern string describes one acceptable shape. When a convention has more than one legitimate form (a component as a plain arrow orReact.forwardRef), give pattern an array of templates, a file passes if it matches any one, and the closest variant is reported when none do. Equivalent type spellings (React.FC ≡ FC, Array<T> ≡ T[]) are folded by sameType.

Released under the MIT License.