@shaenchen/custom-type-enforcement
v1.0.3
Published
Enforce TypeScript type architecture and code quality rules
Downloads
129
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 (can export anything: types, functions, constants, classes) - Export functions/classes from any file
- Define types locally (non-exported) in any file
- Export runtime constants (function calls,
newexpressions, objects with runtime values) - Export TypeBox/Zod schemas (runtime validators)
❌ Forbidden:
- Export types from non-types files
- Export type-like constants (pure literals) from non-types files
export type *pattern (discouraged everywhere, including in types/ directories)
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.
Types/ directories allow anything: Files in types/ directories can export anything—types, type guards, helper functions, constants, and classes. This pragmatic approach recognizes that:
- Type guards naturally co-locate with their discriminated union types
as constarrays are often used with Zod schemas- Default configuration objects belong with their type definitions
// ✅ GOOD: types/alerts.ts - all exports allowed
export interface ThresholdCondition {
threshold: number;
operator: 'gt' | 'lt' | 'eq';
}
export interface RuleCondition {
rules: string[];
}
export type AlertCondition = ThresholdCondition | RuleCondition;
// Type guards - allowed in types/ directory
export function isThresholdCondition(c: AlertCondition): c is ThresholdCondition {
return 'threshold' in c;
}
// Constants - allowed in types/ directory
export const ALERT_TYPES = ['threshold', 'rule'] as const;
export const DEFAULT_TIMEOUT = 5000;Runtime constants are automatically allowed:
// ✅ GOOD: These are runtime values, not type definitions
// Function call initializers
export const logger = pino({ level: 'info' });
export const client = axios.create({ baseURL: 'https://api.example.com' });
// new expressions
export const service = new MyService();
export const pool = new Pool({ connectionString: DB_URL });
// Object literals with runtime values (identifiers, property access, function calls)
export const config = {
url: process.env.DATABASE_URL,
timeout: getDefaultTimeout(),
...baseConfig,
};
// TypeBox/Zod schemas (runtime validators)
export const UserSchema = Type.Object({ name: Type.String() });
export const EmailSchema = z.string().email();// ❌ BAD: These are type-like constants (pure literals)
export const API_KEY = 'my-api-key'; // Primitive literal
export const MAX_RETRIES = 3; // Primitive literal
export const config = { timeout: 5000 }; // Object with only literals
export const items = ['one', 'two']; // Array of literalsAllow 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)
- Import types between sibling types files (types file → types file)
❌ 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' // ✅ GOODSibling types file imports are automatically allowed:
// ✅ GOOD: types/charts.ts importing from types/metrics.ts
// Both files are in the types/ directory - this is allowed
import type { Metric, MetricValue } from './metrics';
import type { ValidationError } from './common';
export interface ChartData {
metric: Metric;
values: MetricValue[];
}Allow 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 |
| --exclude=<patterns> | Comma-separated glob patterns to exclude files | None |
| --help | Show help message | - |
Excluding Files
Use --exclude to skip files matching glob patterns. This is useful for test files, generated code, or other files that don't need type enforcement:
# Exclude test files
npx custom-type-enforcement --exclude="**/*.test.ts,**/*.spec.ts,**/__tests__/**"
# Exclude generated files
npx custom-type-enforcement --exclude="**/generated/**,**/*.generated.ts"
# Combine with specific checks
npx custom-type-enforcement --checks=type-exports --exclude="**/*.test.ts"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.
