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 membersinterface 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. useRefuseStateuseEffectreturn).

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.FCFC, Array<T>T[]) are folded by sameType.

Released under the MIT License.