@shaenchen/custom-type-enforcement
v1.0.1
Published
Enforce TypeScript type architecture and code quality rules
Maintainers
Readme
@shaenchen/custom-type-enforcement
Enforce TypeScript type architecture and code quality rules across any TypeScript project
A portable CLI tool that enforces custom TypeScript type architecture patterns and prevents common code quality issues. Works seamlessly with single packages and monorepos.
Philosophy: Opinionated by Design
This tool enforces opinionated preferences for TypeScript architecture. Not everyone will agree with these rules, and that's okay. These patterns emerged from real-world experience maintaining large TypeScript codebases where certain architectural decisions proved their worth over time.
Why These Rules?
While you might initially wonder "what value does this bring?", these checks address common pain points:
🔍 Findability: When types live in predictable locations (
types.tsfiles), developers know exactly where to look. No more hunting through implementation files or following circular imports.♻️ Maintainability: Named types (vs inline types) are searchable, refactorable, and provide better error messages. Changes propagate cleanly through the codebase.
🎯 Consistency: Enforcing patterns across teams prevents the "every file is different" problem that makes codebases hard to navigate as they scale.
📦 Modularity: Preventing barrel files and enforcing type organization reduces circular dependencies and improves tree-shaking.
Not a One-Size-Fits-All
You don't need to adopt all checks. Use --checks to run only what makes sense for your project:
- Just want type organization? Run
--checks=type-exports,type-imports - Just want to reduce duplication? Run
--checks=type-duplicates - Have legitimate use cases for barrel files? Skip the
barrel-filescheck
These rules work best for medium-to-large codebases where consistency and maintainability matter more than individual file convenience. For smaller projects or prototypes, some rules might feel like overkill—and that's a valid perspective.
Features
- 📦 Architecture: Enforce clean type organization and imports
- 🔍 Quality: Prevent code smells like barrel files and inline types
- ♻️ Maintainability: Reduce type duplication across your codebase
- 🚀 Fast: Minimal dependencies, runs in seconds
- 🎯 Configurable: Run all checks or selectively via CLI flags
- 🌳 Monorepo-ready: Works with npm workspaces and multi-package repos
Table of Contents
- Philosophy: Opinionated by Design
- Installation
- Quick Start
- Available Checks
- CLI Options
- Configuration
- Monorepo Usage
- Ignore Patterns
- Output Formats
- Contributing
- License
Installation
npm install -D @shaenchen/custom-type-enforcementOr use directly with npx:
npx @shaenchen/custom-type-enforcementQuick Start
Run all checks in your TypeScript project:
npx custom-type-enforcementRun specific checks only:
npx custom-type-enforcement --checks=barrel-files,type-exportsAdd to your package.json scripts:
{
"scripts": {
"check": "custom-type-enforcement",
"check:types": "custom-type-enforcement --checks=type-exports,type-imports"
}
}Available Checks
1. Barrel Files Check
Purpose: Prevent pure wrapper files that only re-export from other files.
Detects: Files that ONLY contain:
export { ... } from '...'export * from '...'export type { ... } from '...'
Rationale:
- Makes dependencies explicit
- Prevents circular dependencies
- Improves tree-shaking
- Clearer code navigation
Example violation:
// ❌ BAD: Pure barrel file (utils/index.ts)
export { formatDate } from './date'
export { parseUrl } from './url'
export * from './helpers'
// ✅ GOOD: Add actual implementation
export { formatDate } from './date'
export { parseUrl } from './url'
// Utility function in this file
export function compose(...fns) {
return (x) => fns.reduceRight((acc, fn) => fn(acc), x)
}Allow barrel files when needed:
// @barrel-file-allowed
export * from './components'2. Type Exports Check
Purpose: Enforce that types/interfaces/enums are only exported from types.ts or types/{domain}.ts files.
Rules:
✅ Allowed:
- Export types from
types.tsfiles - Export types from
types/{domain}.tsfiles - Export functions/classes from any file
- Define types locally (non-exported) in any file
❌ Forbidden:
- Export types from non-types files
- Export non-functional constants from non-types files
export type *pattern (discouraged everywhere)
Example violations:
// ❌ BAD: Exporting types from implementation file (user.service.ts)
export interface User {
id: string
name: string
}
export class UserService {
// ...
}
// ✅ GOOD: Move types to types.ts
// types.ts
export interface User {
id: string
name: string
}
// user.service.ts
import type { User } from './types'
export class UserService {
// ...
}File path validation: Types must be in files ending with types.ts or containing /types/ in the path.
Allow type exports when needed:
// @type-export-allowed
export interface LegacyType {
// Special case that needs to be exported from this file
}3. Type Imports Check
Purpose: Enforce that type imports only come from types.ts or types/{domain}.ts files.
Rules:
✅ Allowed:
- Import types from
types.tsfiles - Import types from
types/{domain}.tsfiles - Import types from external packages (npm)
❌ Forbidden:
- Import types from implementation files
- Import types from non-types files
Example violations:
// ❌ BAD: Importing type from implementation file
import type { User } from './user.service'
// ✅ GOOD: Import from types file
import type { User } from './types'
// ✅ GOOD: Import from external package
import type { Request, Response } from 'express'Mixed imports:
// Both syntax variants are detected:
import type { User, Product } from './services' // ❌ BAD
import { type User, UserService } from './services' // ❌ BAD (type from non-types file)
import type { User } from './types' // ✅ GOOD
import { UserService } from './services' // ✅ GOODAllow type imports when needed:
// @type-import-allowed
import type { SpecialType } from './external-library-wrapper'4. Type Duplicates Check
Purpose: Warn about structurally similar types that could be consolidated.
Detects:
- Exact structural matches (field order independent)
- Optional field variance (same required fields, different optionals)
- Composition opportunities (subset relationships → use
Pick/Omit) Required<T>opportunities (same fields, different optionality)- Intersection opportunities (could use
Type1 & Type2)
Example violations:
// ❌ BAD: Duplicate types
// types/user.ts
export interface User {
id: string
name: string
email: string
}
// types/customer.ts
export interface Customer {
id: string
name: string
email: string
}
// ✅ GOOD: Use composition
// types/user.ts
export interface BaseUser {
id: string
name: string
email: string
}
export type User = BaseUser
export type Customer = BaseUser
// Or even better, just use one type:
export interface User {
id: string
name: string
email: string
}Composition opportunities:
// ❌ BAD: Subset duplication
export interface User {
id: string
name: string
email: string
role: string
}
export interface PublicUser {
id: string
name: string
}
// ✅ GOOD: Use Pick
export interface User {
id: string
name: string
email: string
role: string
}
export type PublicUser = Pick<User, 'id' | 'name'>Ignore duplicates:
// @type-duplicate-allowed
export interface SpecialCaseType {
// Intentionally duplicated for specific reason
}Smart filtering:
- Ignores types with < 2 fields (too generic)
- Ignores types in the same file (already co-located)
- Only checks types in
types.tsfiles
5. Inline Types Check
Purpose: Discourage inline object types in favor of named interfaces/types.
Detects:
- Type assertions:
as { ... } - Variable declarations:
const x: { ... } - Function parameters:
function foo(param: { ... }) - Return types:
): { ... }
Allows:
- Generic constraints:
<T extends { ... }> - Mapped/conditional types:
{ [P in keyof T]: ... } - Named type/interface declarations
Example violations:
// ❌ BAD: Inline object types
function createUser(data: { name: string; email: string }) {
return data
}
const config: { apiKey: string; timeout: number } = {
apiKey: 'xxx',
timeout: 5000
}
// ✅ GOOD: Named types
interface UserData {
name: string
email: string
}
interface Config {
apiKey: string
timeout: number
}
function createUser(data: UserData) {
return data
}
const config: Config = {
apiKey: 'xxx',
timeout: 5000
}Rationale:
- Named types have better error messages
- More searchable and refactorable
- Self-documenting (type name explains intent)
- Can be reused across the codebase
Allow inline types when appropriate:
// @inline-type-allowed
function legacyApi(opts: { debug: boolean }) {
// One-off parameter that won't be reused
}CLI Options
custom-type-enforcement [options]Options
| Option | Description | Default |
|--------|-------------|---------|
| --checks=<checks> | Comma-separated list of checks to run | All checks |
| --help | Show help message | - |
Available Checks
barrel-files- Prevent pure re-export filestype-exports- Enforce type export architecturetype-imports- Enforce type import architecturetype-duplicates- Warn about duplicate typesinline-types- Discourage inline object types
Examples
Run all checks (default):
npx custom-type-enforcementRun specific checks:
npx custom-type-enforcement --checks=barrel-files,type-exports
npx custom-type-enforcement --checks=type-exports,type-importsGet help:
npx custom-type-enforcement --helpConfiguration
package.json Integration
Add to your package.json scripts:
{
"scripts": {
"check": "custom-type-enforcement",
"check:types": "custom-type-enforcement --checks=type-exports,type-imports,type-duplicates",
"check:quality": "custom-type-enforcement --checks=barrel-files,inline-types",
"precommit": "custom-type-enforcement"
}
}CI/CD Integration
Add to your GitHub Actions workflow:
name: Code Quality
on: [push, pull_request]
jobs:
quality-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- run: npx custom-type-enforcementPre-commit Hook
Using husky:
{
"husky": {
"hooks": {
"pre-commit": "custom-type-enforcement"
}
}
}Monorepo Usage
The tool runs from wherever it's called (uses process.cwd()). For monorepos, delegate checks via npm workspaces.
Root package.json
{
"name": "my-monorepo",
"private": true,
"workspaces": ["packages/*"],
"scripts": {
"check": "npm run check --workspaces --if-present",
"check:types": "npm run check:types --workspaces --if-present"
},
"devDependencies": {
"@shaenchen/custom-type-enforcement": "^1.0.0"
}
}Workspace package.json
{
"name": "@myorg/api",
"scripts": {
"check": "custom-type-enforcement",
"check:types": "custom-type-enforcement --checks=type-exports,type-imports"
}
}Running checks
# Check all workspaces
npm run check
# Check specific workspace
npm run check -w @myorg/apiIgnore Patterns
Suppress false positives using comment directives:
Barrel Files Check
// @barrel-file-allowed
export * from './components'
export * from './utils'Type Exports Check
// @type-export-allowed
export interface LegacyType {
// Special case requiring export from this file
}
// Can also be placed on the same line
export type SpecialCase = {}; // @type-export-allowedType Imports Check
// @type-import-allowed
import type { ExternalType } from './wrapper'
// Can also be placed on the same line
import type { LibraryType } from './lib'; // @type-import-allowedType Duplicates Check
// @type-duplicate-allowed
export interface SpecialCase {
// Intentionally similar to another type
id: string
name: string
}Inline Types Check
// @inline-type-allowed
function legacyFunction(opts: { debug: boolean }) {
// One-off parameter
}Output Format
The tool uses a minimal, LLM-optimized output format designed for efficient token usage.
Success (all checks pass):
✓ All checks passedFailures (with violations):
✗ 2 checks failed (3 violations)
type-exports (2):
src/services/user.service.ts:5: Type exported from non-types file
src/utils/helpers.ts:12: Interface exported from non-types file
Fix: Move type/interface/enum exports to types.ts or types/{domain}.ts files
Keep functional exports (functions, classes) in implementation files
Use type composition (Pick, Omit, &) to create variations of types
To suppress: Add // @type-export-allowed comment on same line or line above
inline-types (1):
src/app.ts:15: Inline type in function parameter
Fix: Extract inline types to named type aliases or interfaces
Define types in appropriate types.ts files
Use descriptive type names that explain the purpose
To suppress: Add // @inline-type-allowed comment on same line or line aboveThis format:
- Uses 80-90% fewer tokens than the previous format
- Shows only failed checks
- Groups violations by check type
- Provides actionable fix instructions
- Includes suppress instructions at the end of each Fix section
Contributing
Contributions are welcome! Here's how to add a new check:
1. Create Check Implementation
Create src/checks/my-check.ts:
import { getTypeScriptFiles } from '../lib/get-typescript-files.js'
import { Formatter } from '../lib/formatter.js'
import type { CheckOptions, CheckResult } from '../types.js'
import * as fs from 'fs'
/**
* Run my custom check
*/
export function runMyCheck(options: CheckOptions = {}): CheckResult | void {
const formatter = new Formatter('My Check')
formatter.start()
const files = getTypeScriptFiles({ projectRoot: options.projectRoot })
if (!files) {
console.error('ERROR: No TypeScript files found')
process.exit(1)
}
for (const file of files) {
const content = fs.readFileSync(file, 'utf-8')
const lines = content.split('\n')
lines.forEach((line, index) => {
// Your check logic here
if (/* violation detected */) {
formatter.addViolation({
file,
line: index + 1,
content: line.trim(),
message: 'Violation description',
severity: 'HIGH',
})
}
})
}
const exitCode = formatter.finish({
blocking: true,
noExit: options.noExit,
howToFix: [
'How to fix this issue',
'Alternative solution'
],
whyItMatters: [
'Why this check is important',
'What problems it prevents'
],
})
if (options.noExit) {
return {
checkName: 'my-check',
passed: exitCode === 0,
violationCount: formatter.getViolationCount(),
exitCode,
violations: formatter.getViolations().map(v => ({
file: v.file,
line: v.line,
message: v.message ?? 'Violation detected',
})),
howToFix: [
'How to fix this issue',
'Alternative solution'
],
suppressInstruction: 'To suppress: Add // @my-check-allowed comment on same line or line above',
}
}
}2. Add to CLI
Update src/cli/index.ts:
import { runMyCheck } from '../checks/my-check.js'
const CHECK_RUNNERS: Record<CheckName, CheckRunner> = {
// ... existing checks
'my-check': runMyCheck,
}3. Update Types
Add to src/types.ts:
export type CheckName =
| 'barrel-files'
// ... existing checks
| 'my-check'4. Write Tests
Create tests/my-check.test.ts:
import { describe, it, expect } from 'vitest'
import { runMyCheck } from '../src/checks/my-check'
describe('My Check', () => {
it('should detect violations', () => {
// Test implementation
})
})5. Update Documentation
Add check description to README.md.
Requirements
- Node.js: >= 18.0.0
- TypeScript: Project must have
tsconfig.jsonin root directory
Troubleshooting
Error: No tsconfig.json found
The tool must be run from a TypeScript project root containing tsconfig.json.
# Make sure you're in the right directory
cd /path/to/your/typescript/project
# Verify tsconfig.json exists
ls tsconfig.json
# Run the tool
npx custom-type-enforcementMonorepo: Checks not running in workspaces
Make sure:
- Each workspace has the check script in its
package.json - You're using
--workspaces --if-presentin the root script - The tool is installed (either in root or workspace
devDependencies)
False positives
Use ignore comments to suppress false positives:
// @barrel-file-allowed- Allow barrel files// @type-export-allowed- Allow type exports from non-types files// @type-import-allowed- Allow type imports from non-types files// @type-duplicate-allowed- Ignore type duplicates// @inline-type-allowed- Allow inline object types
All flags can be placed either on the same line or on the line above the code.
License
MIT © Steve Haenchen
See LICENSE file for details.
Links
Built with ❤️ to enforce code quality and architectural consistency in TypeScript projects.
