Skip to content

AndrewDongminYoo/rn-typed-assets

Repository files navigation

rn-typed-assets

Get rid of all string-based asset references in React Native — forever.

rn-typed-assets scans your asset directories and generates a typed TypeScript registry, so that every image, SVG, and Lottie animation is accessed through a named constant instead of a brittle require('../../../assets/icon.png') path. If the file doesn't exist, the import fails at generation time — not at runtime.

// Before
<Image source={require('../../../assets/toast/info.png')} />
<LottieView source={require('../../utils/loading.json')} />

// After
import { Assets, Lotties } from '../generated/assets.gen';

<Image source={Assets.toast.info} />
<LottieView source={Lotties.loading} />

Inspiration

This tool is directly inspired by two codegen tools from other ecosystems:

  • SwiftGen — the canonical Swift code generator for Xcode resources. SwiftGen pioneered the pattern of scanning asset catalogs and emitting fully type-safe Swift enums, eliminating string-based UIImage(named:) calls entirely.

  • FlutterGen — the Flutter equivalent. FlutterGen reads pubspec.yaml assets and emits Dart classes with typed getters, so Assets.images.profile.image() replaces Image.asset('assets/images/profile.jpg').

rn-typed-assets brings the same discipline to React Native: a manifest-driven, deterministic generator that makes unused or mistyped asset references a compile-time (or generation-time) problem rather than a runtime crash.

Features

  • Zero-config for standard RN projects — works out of the box with the default src/assets layout
  • Three built-in asset typesimage (PNG/JPG/WebP), svg, lottie (JSON)
  • Deterministic, sorted output — generated files are stable across runs and friendly to code review
  • Collision detection — files that normalize to the same key (e.g. harini-cry.png and harini_cry.png) are caught at generation time with a clear error
  • Manifest-backed audit — find and optionally delete unused assets that are no longer referenced anywhere in source
  • Automatic source rewritinggenerate --inplace and organize rewrite every require() call, ES module import statement, and stale dotted reference in your source files to match the regenerated manifest
  • Content-hash diffing — each manifest entry now carries a SHA-1 hash of the file's bytes, enabling the codemod to track files that move or are renamed without content changes
  • Asset organizationorganize migrates flat or legacy asset directories into canonical subdirectories (images/, svg/, lottie/) in one command
  • Configurable — override paths, export names, TypeScript type imports, or add entirely new asset types via rn-typed-assets.config.js
  • Programmatic API — every function is exported; integrate the generator into your own scripts or build tools
  • Lightweight — zero runtime dependencies; typescript is a peer dependency used only by the audit command

How It Works

src/assets/                        commands/
  toast/info.png    ──┐            generate
  toast/warning.png ──┤  scan  ──► assets.gen.ts        (typed require() registry)
  lottie/loading.json─┤            assets.manifest.json (path ↔ key + contentHash)
  svg/logo.svg ───────┘

                                   generate --inplace
src/**/*.{ts,tsx,js,jsx} ◄──────  rewrite require() → Assets.*
                                   migrate import X → Assets.*
                                   update stale dotted references

                                   organize src/assets
  svg/logo.svg ───────► svg/logo.svg   (move to canonical dir)
                          → regenerate + rewrite sources

                                   audit
src/**/*.{ts,tsx,js,jsx} ──────►  compare usages in source
                                   vs. entries in manifest
                                   → report (or delete) unused assets

Generation pipeline

  1. Scan — For each enabled asset type, recursively list files under the configured rootDir.
  2. Normalize — Convert each filename to a stable camelCase key (harini-cry.pnghariniCry, 1.pngn1).
  3. Build registry tree — Assemble a nested object tree from path segments. Detect and resolve branch/leaf collisions automatically (e.g. a file named point.png alongside a point/ directory becomes pointAsset).
  4. Emit — Write assets.gen.ts (a typed as const object) and assets.manifest.json (a stable index of every key ↔ file mapping).

Audit pipeline

  1. Parse source files — Use the TypeScript Compiler API to walk the AST of every .ts/.tsx/.js/.jsx file under sourceRoots.
  2. Collect usages — Detect Assets.*, Lotties.*, and Svgs.* property-access chains. Also collect legacy require('../assets/...') calls for files still in migration.
  3. Compare against manifest — Any key present in the manifest but unreferenced in source is reported as unused. Any key used in source but absent from the manifest is reported as unknown.
  4. Optionally fix — With --fix, delete the unused files and regenerate the manifest.

Installation

npm install --save-dev rn-typed-assets

typescript must be available in the project (it is a peerDependency). Most React Native projects already have it as a dev dependency.

Quick Start

1. Add scripts to package.json

{
  "scripts": {
    "assets:generate": "rn-typed-assets generate",
    "assets:generate:inplace": "rn-typed-assets generate --inplace",
    "assets:organize": "rn-typed-assets organize src/assets",
    "assets:audit": "rn-typed-assets audit",
    "assets:audit:fix": "rn-typed-assets audit --fix"
  }
}

2. Run the generator

npm run assets:generate

This writes two files to src/generated/:

  • assets.gen.ts — the typed registry you import in your components
  • assets.manifest.json — the index used by the audit command (commit this file)

3. Import the registry

import { Assets, Lotties, Svgs } from './generated/assets.gen';

// Images
<Image source={Assets.toast.info} />
<Image source={Assets.coupang.hariniCry} />

// Lottie
<LottieView source={Lotties.loading} autoPlay loop />

// SVG — see "SVG type configuration" below for usage patterns
const logo = Svgs.logo;

4. Audit for unused assets

npm run assets:audit          # report unused entries
npm run assets:audit -- --fix # delete unused files and regenerate

CLI Reference

rn-typed-assets <command> [options]

Common options

These flags work with every command (generate, audit, organize):

Flag Description Default
--case <camel|snake> Casing style for generated asset keys camel
--on-collision <error|first> When two files normalize to the same key: error fails the run, first keeps the first sorted path and drops the rest error
--output <text|json> Output format. json prints exactly one machine-readable envelope to stdout and suppresses all other logs text
--root <path> Project root directory cwd
--config <path> Path to config file ./rn-typed-assets.config.js

CLI flags override the equivalent keyCase / onCollision settings in your config file.

JSON output (for AI agents and scripting)

With --output json, every command emits exactly one JSON envelope to stdout and writes nothing else there. The process exit code still signals success (0) or failure (1), so callers can read either the stream or the code.

{ "ok": true, "command": "generate", "data": { "...": "..." }, "error": null }

On failure (ok: false, non-zero exit) data is null and error.message describes the problem:

{ "ok": false, "command": "generate", "data": null, "error": { "message": "..." } }

The data shape is the per-command contract:

Command data fields
generate types: string[], count: number, rewrittenFiles: number, collisions: Collision[], entries: { type, keyPath, filePath, modulePath }[]
audit types: string[], manifestMatchesFilesystem: boolean, unknownGeneratedUsages: string[], unusedEntries: string[], deletedFiles?: string[] (with --fix)
organize types: string[], count: number, movedFiles: string[], rewrittenFiles: number, collisions: Collision[]

A Collision is { type, keyPath, kept, dropped } and is only populated under --on-collision first (in text mode these surface as Collision: lines). Note that audit reports ok: false and exits 1 when the manifest is stale or unknown generated usages are detected; unused entries alone are reported with ok: true.

rn-typed-assets generate --types=image --output json
rn-typed-assets generate --case snake            # snake_case keys
rn-typed-assets generate --on-collision first    # keep first path, drop duplicates

Keep casing consistent across commands. generate and audit must use the same keyCase, or audit will regenerate keys in a different casing and report the manifest as stale. Prefer setting keyCase in the config file (both commands read it) over passing --case to only one command.

generate

Scan asset directories and emit assets.gen.ts + assets.manifest.json.

Flag Description Default
--types <types> Comma-separated list of asset types to include image,svg,lottie
--inplace Rewrite source files to replace stale references after regen false
--root <path> Project root directory cwd
--config <path> Path to config file ./rn-typed-assets.config.js
rn-typed-assets generate
rn-typed-assets generate --inplace          # rewrite sources after regen
rn-typed-assets generate --types=image,lottie
rn-typed-assets generate --root=/path/to/project

When --inplace is set, the tool loads the previous manifest before writing the new one, diffs them by content hash, and rewrites every source file that contains a stale require() path, an ES module import statement for an asset file, or a renamed dotted symbol reference.

ES module imports are fully migrated: the import declaration is removed, all usages of the imported binding are replaced with the generated symbol, and the import { … } from '…/assets.gen' statement is added or extended automatically.

// Before
import logo from '../assets/logo.png';
const el = <Image source={logo} />;

// After (generated by --inplace)
import { Assets } from '../generated/assets.gen';
const el = <Image source={Assets.logo} />;

organize

Move asset files from legacy or flat directories into canonical subdirectories, then regenerate and rewrite sources.

rn-typed-assets organize <assetsDir>
Argument Description
<assetsDir> Path to the asset root to organize (relative to cwd)
Flag Description Default
--types <types> Asset types to move image,svg,lottie
--root <path> Project root directory cwd
--config <path> Path to config file ./rn-typed-assets.config.js
rn-typed-assets organize src/assets

Canonical subdirectory layout after organize:

src/assets/
  images/    ← PNG/JPG/WebP
  svg/       ← SVG
  lottie/    ← Lottie JSON

Files already in the canonical location are left untouched. Source files with require() calls pointing to the old paths are rewritten automatically.

audit

Compare manifest against actual source-file usages.

Flag Description Default
--types <types> Asset types to include in the audit image,svg,lottie
--fix Delete unused asset files and regenerate the manifest false
--root <path> Project root directory cwd
--config <path> Path to config file ./rn-typed-assets.config.js
rn-typed-assets audit
rn-typed-assets audit --fix
rn-typed-assets audit --types=image

Configuration

Create rn-typed-assets.config.js in your project root to override any default. The file is optional — omitting it is equivalent to accepting all defaults.

// rn-typed-assets.config.js
module.exports = {
  // Where to write assets.gen.ts and assets.manifest.json
  // Default: 'src/generated'
  outputDir: 'src/generated',

  // Directories and entry files scanned by the audit command
  // Default: ['src', 'App.tsx', 'index.js']
  sourceRoots: ['src', 'App.tsx', 'index.js'],

  // Casing style for generated asset keys: 'camel' | 'snake'
  // Default: 'camel'  (e.g. 'home-button.png' -> homeButton vs. home_button)
  keyCase: 'camel',

  // Collision policy when two files normalize to the same key: 'error' | 'first'
  // 'error' fails the run; 'first' keeps the first sorted path and drops the rest.
  // Default: 'error'
  onCollision: 'error',

  // Per-type configuration (all fields are optional overrides)
  types: {
    image: {
      rootDir: 'src/assets', // scan root
      extensions: ['.png', '.jpg', '.jpeg', '.webp'], // included extensions
      exportName: 'Assets', // export const Assets = ...
      typeImport: {
        typeName: 'ImageRequireSource', // TypeScript type name to import
        from: 'react-native', // import source
      },
      // valueType: 'React.FC<ImageProps>', // annotation applied to values (defaults to typeImport.typeName)
    },
    svg: {
      rootDir: 'src/assets/svg',
      extensions: ['.svg'],
      exportName: 'Svgs',
      // SVG type depends on your setup — see "SVG type configuration" section
      inlineType: 'unknown',
    },
    lottie: {
      rootDir: 'src/assets/lottie',
      extensions: ['.json'],
      exportName: 'Lotties',
      typeImport: {
        typeName: 'AnimationObject',
        from: 'lottie-react-native',
      },
    },
  },
};

SVG type configuration

SVG files behave differently depending on your Metro setup. The default inlineType: 'unknown' is intentionally conservative — override it in your config to match your project.

Pattern A — with react-native-svg-transformer

require('./logo.svg') returns a React component. Configure a real type import and a valueType:

// rn-typed-assets.config.js
module.exports = {
  types: {
    svg: {
      typeImport: { typeName: 'SvgProps', from: 'react-native-svg' },
      valueType: 'React.FC<SvgProps>',
    },
  },
};

Usage:

const Logo = Svgs.splash.logo; // React.FC<SvgProps>
<Logo width={24} height={24} />;

Pattern B — without transformer, using SvgUri

require('./logo.svg') returns a number (Metro asset ID), just like images. You must resolve it to a URI string before passing to SvgUri.uri:

// rn-typed-assets.config.js
module.exports = {
  types: {
    svg: {
      typeImport: { typeName: 'ImageRequireSource', from: 'react-native' },
    },
  },
};

Usage:

import { Asset } from 'expo-asset'; // Expo
const uri = Asset.fromModule(Svgs.splash.logo).uri;
<SvgUri uri={uri} width={24} height={24} />;

Note: <SvgUri uri={Svgs.splash.logo} /> will not work directly — SvgUri.uri is string | null, but require() without transformer returns number.

Adding a custom asset type

Any type not in the defaults can be added under types. The audit command discovers it automatically via the exportNametype reverse map.

module.exports = {
  types: {
    font: {
      rootDir: 'src/assets/fonts',
      extensions: ['.ttf', '.otf'],
      exportName: 'Fonts',
      inlineType: 'string', // emits: export type FontsAssetSource = string
    },
  },
};

Generated Output

Given this asset tree:

src/assets/
  toast/
    info.png
    warning.png
  coupang/
    harini-cry.png
  lottie/
    loading.json
  svg/
    logo.svg

Running rn-typed-assets generate produces:

// src/generated/assets.gen.ts
/* eslint-disable @typescript-eslint/no-require-imports -- require() is intentional for React Native static asset bundling */
// Auto-generated by rn-typed-assets. Do not edit manually.

import type { ImageRequireSource } from 'react-native';
import type { AnimationObject } from 'lottie-react-native';
export type SvgsAssetSource = unknown;

export const Assets = {
  coupang: {
    hariniCry:
      require('../assets/coupang/harini-cry.png') as ImageRequireSource,
  },
  toast: {
    info: require('../assets/toast/info.png') as ImageRequireSource,
    warning: require('../assets/toast/warning.png') as ImageRequireSource,
  },
} as const;

export const Lotties = {
  loading: require('../assets/lottie/loading.json') as AnimationObject,
} as const;

export const Svgs = {
  logo: require('../assets/svg/logo.svg') as SvgsAssetSource,
} as const;

Key normalization rules

Filename keyCase: 'camel' (default) keyCase: 'snake'
harini-cry.png hariniCry harini_cry
camera_guide.png cameraGuide camera_guide
Info-Filled.png infoFilled info_filled
1.png n1 (numeric prefix → n) n_1
point.png alongside point/ dir pointAsset (leaf/branch collision) point_asset

Set the casing via the keyCase config option or the --case <camel\|snake> CLI flag.

Programmatic API

All functions are available for use in custom build scripts:

const {
  loadConfig,
  collectAssetEntries,
  generateAssetsModule,
  generateAssetsManifest,
  writeGeneratedAssets,
  auditAssetUsage,
  collectGeneratedAssetUsages,
  // Codemod
  collectAssetImportBindings,
  diffAssetManifests,
  rewriteTypedAssetSource,
} = require('rn-typed-assets');

const config = loadConfig(projectRoot);

// Generate
const { entries, moduleContent, manifest } = writeGeneratedAssets({
  projectRoot,
  types: ['image', 'lottie'],
  config,
});

// Audit (requires typescript peer dep)
const usages = collectGeneratedAssetUsages(sourceCode, filePath, config);
const report = auditAssetUsage({
  manifest,
  generatedUsages: usages,
  requirePaths: [],
  config,
});

// Codemod — rewrite a source file after a manifest change
const { renamedSymbols, currentSymbolsByFilePath } = diffAssetManifests({
  previousManifest,
  nextManifest,
  config,
});

const { changed, code } = rewriteTypedAssetSource({
  code: fs.readFileSync(filePath, 'utf8'),
  filePath, // relative to projectRoot
  previousManifest,
  nextManifest,
  projectRoot,
  config,
});

if (changed) {
  fs.writeFileSync(filePath, code);
}

See src/index.js for the full list of exported functions.

CI Integration

Add generation and audit to your CI pipeline to catch drift between the manifest and the filesystem:

# .github/workflows/ci.yml
- name: Verify asset manifest is up to date
  run: |
    npm run assets:generate
    git diff --exit-code src/generated/

Or use a pre-commit hook via Husky:

# .husky/pre-commit
npm run assets:generate
git add src/generated/assets.gen.ts src/generated/assets.manifest.json

Future Plans

Watch mode

Automatically re-run generate when files under any rootDir are added, removed, or renamed. Useful during active development without having to manually invoke the CLI.

rn-typed-assets generate --watch   # planned

Metro plugin integration

A Metro resolver plugin that runs generation as part of the bundler's startup, ensuring assets.gen.ts is always in sync before the first bundle is produced — eliminating the need for a separate pre-build step.

Husky / lint-staged recipe

A first-class init command that scaffolds a Husky pre-commit hook and lint-staged configuration so that generation runs automatically whenever asset files are staged.

rn-typed-assets init --hooks   # planned

Font and audio asset types

Extend the default type set to cover fonts (.ttf, .otf) and audio files (.mp3, .wav, .aac) with appropriate TypeScript types (FontSource, NodeRequire). These types already work today via the custom-type config API; the plan is to promote them to built-in defaults.

Strict audit mode for CI

A --strict flag for the audit command that exits non-zero if the manifest is stale, if any unknown keys are in use, or if any unused entries remain — making the full audit a one-command CI gate.

rn-typed-assets audit --strict   # planned

Monorepo support

Allow multiple packages in a workspace to share a single invocation, with per-package output directories and per-package manifests, controlled by a root-level config or workspace glob.

Output template customization

Inspired by SwiftGen's Stencil templates, allow users to supply a custom EJS or Handlebars template for assets.gen.ts, enabling alternative output styles — for example, emitting functions instead of require() literals, or targeting a custom asset loader.

License

MIT

About

React Native typed asset registry — generates type-safe Assets/Svgs/Lotties constants, audits unused files, and rewrites stale imports automatically

Topics

Resources

License

Stars

1 star

Watchers

0 watching

Forks

Contributors