Grunt Multi-Task Configuration: File Globbing and Dynamic Targets for Declarative Builds
Grunt's multi-task system uses a single config object with expand:true and glob patterns to process discovered files declaratively. Worked uglify example, dynamic targets, templates, negation glob ordering, verification steps, and why new projects should consider modern alternatives.
07 Feb 2026, 13:54 UTC

The Problem: Declarative File Processing Without Hardcoding Paths
Grunt's multi-task system solves a specific engineering problem: how to process arbitrary sets of files — discovered at runtime — through a pipeline of plugins without writing imperative code for each file. The configuration model lets you declare source-destination mappings using glob patterns and templates, then have tasks like concat, uglify, or copy automatically iterate over every matching file. This article shows how to structure that configuration, where the sharp edges are, and how to verify the result.
Core Mechanism: The files Array with expand: true
Each multi-task target accepts a files array. When expand: true is set, Grunt treats src as glob patterns relative to cwd, writes each matched file to dest (optionally renaming with ext or extDot), and preserves directory structure. This single pattern replaces dozens of explicit {src: 'a.js', dest: 'b.js'} entries.
Worked Example: Minifying Discovered JavaScript Files
Assume a project layout:
project/
├── Gruntfile.js
├── package.json
├── src/
│ ├── app.js
│ ├── utils/
│ │ ├── helpers.js
│ │ └── constants.js
│ └── legacy/
│ └── old-code.js
└── dist/ (output directory, created by the task)
Install the minifier plugin:
npm install --save-dev grunt grunt-contrib-uglify
Run from the project root (requires Node ≥ 8 for Grunt 1.x). The Gruntfile.js below reads package.json for version metadata, defines a uglify multi-task with a dist target that discovers every .js file under src/, and writes minified counterparts to dist/ preserving the subdirectory tree.
module.exports = function(grunt) {
// Load package metadata for template use
const pkg = grunt.file.readJSON('package.json');
grunt.initConfig({
pkg: pkg,
uglify: {
options: {
banner: '/*! <%= pkg.name %> v<%= pkg.version %> <%= grunt.template.today("yyyy-mm-dd") %> */\n',
sourceMap: true
},
dist: {
files: [{
expand: true, // enable dynamic expansion
cwd: 'src', // source root
src: ['**/*.js'], // recursive glob
dest: 'dist', // destination root
ext: '.min.js' // rename .js → .min.js
}]
}
}
});
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.registerTask('default', ['uglify:dist']);
};
Run the task:
grunt uglify:dist
Expected output in dist/:
dist/
├── app.min.js
├── utils/
│ ├── helpers.min.js
│ └── constants.min.js
└── legacy/
└── old-code.min.js
Each output file contains the banner with interpolated pkg.name, pkg.version, and today's date. The sourceMap: true option generates .map files alongside.
Dynamic Target Generation at Runtime
Because grunt.initConfig receives a plain JavaScript object, you can compute targets before calling it. A common pattern: read a directory of entry points and generate a concat target per entry.
const fs = require('fs');
const path = require('path');
module.exports = function(grunt) {
const entries = fs.readdirSync('src/entries')
.filter(f => f.endsWith('.js'))
.map(f => path.basename(f, '.js'));
const concatTargets = {};
entries.forEach(name => {
concatTargets[name] = {
src: [`src/entries/${name}.js`, `src/shared/**/*.js`],
dest: `dist/${name}.bundle.js`
};
});
grunt.initConfig({
concat: concatTargets
});
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.registerTask('build', ['concat']);
};
Running grunt concat:admin builds only the admin bundle; grunt concat builds all discovered targets. This avoids editing the Gruntfile when entries are added or removed.
Template Processing: Keeping Configuration DRY
Grunt processes <%= ... %> (config values) and <% ... %> (inline JavaScript) in string values before passing them to tasks. The pkg object from grunt.file.readJSON('package.json') is the conventional place for version, name, license, and paths.
Example: a copy target that stages a release zip named after the package version.
compress: {
release: {
options: {
archive: 'releases/<%= pkg.name %>-<%= pkg.version %>.zip'
},
files: [{
expand: true,
cwd: 'dist',
src: ['**'],
dest: '<%= pkg.name %>-<%= pkg.version %>'
}]
}
}
Changing package.json updates the archive name and internal folder without touching the Gruntfile.
Negation Globs: Order Matters
Minimatch (Grunt's glob engine) evaluates patterns left-to-right. A negation pattern !**/*.spec.js must appear after the positive pattern that would otherwise match it.
src: ['**/*.js', '!**/*.spec.js', '!**/test/**']
Reversing the order (!**/*.spec.js before **/*.js) causes the negation to be ignored because no file has been matched yet. This is a frequent source of "why is my test file still being minified?" bugs.
Task Composition and Target Invocation
grunt.registerTask('build', ['clean', 'jshint', 'concat', 'uglify']) runs tasks sequentially. Multi-tasks iterate all their targets unless a specific target is named on the CLI: grunt uglify:dist runs only the dist target of uglify. List available targets with grunt --help — they appear as task:target.
If no default task is registered and no CLI argument is given, Grunt exits with a warning but exit code 0. CI scripts must call a named task explicitly (e.g., grunt ci) to avoid silent success.
Escape Hatches: Async Tasks and grunt.file API
When declarative configuration isn't enough, custom tasks can use this.async() to signal asynchronous completion and the grunt.file API for imperative filesystem work.
grunt.registerTask('generate-manifest', 'Create a file list manifest', function() {
const done = this.async();
const files = grunt.file.expand({cwd: 'dist'}, '**/*');
const manifest = {generated: new Date().toISOString(), files};
grunt.file.write('dist/manifest.json', JSON.stringify(manifest, null, 2));
done();
});
This runs after uglify in a registerTask chain: grunt.registerTask('release', ['uglify', 'generate-manifest', 'compress']).
Limits and Common Mistakes
Config Bloat
The single initConfig object grows linearly with targets and plugins. Projects with 20+ tasks often split configuration into separate modules and require them, but the merge is still manual. This is a primary driver for migration to modular build tools.
Node.js Compatibility
Grunt 1.x requires Node ≥ 8. Older plugins (pre-2018) may depend on the removed domain module or other deprecated APIs, causing crashes on Node 18+. Verify by running the Gruntfile under the target Node version (e.g., nvm use 18 && grunt) before committing to a CI matrix.
Maintenance Mode
Grunt's last release was 2023. No new features are planned. For new projects, evaluate npm scripts + esbuild, vite, or turbo for faster builds and active ecosystems. Existing Grunt pipelines remain functional but will not gain performance improvements or modern language support.
Glob Negation Order
As noted, negation patterns must follow positive patterns in the same src array. Splitting across multiple files objects does not work — each object is expanded independently.
Template Scope
Templates only resolve in string values inside initConfig. They do not work in JavaScript logic outside the config object (e.g., inside a custom task function). Use grunt.config.get('pkg.version') there instead.
Verification Checklist
- Inspect resolved config: Run
grunt --verboseor addconsole.log(grunt.config.get('uglify'))afterinitConfigto confirm template expansion and dynamic target structure. - Test glob expansion: Create a temporary file
src/temp.js, rungrunt uglify:dist, verifydist/temp.min.jsappears. Delete the temp file, run again, confirm it's gone fromdist/(Grunt does not clean stale outputs automatically). - Validate negation: Add
src/ignore.jsandsrc: ['**/*.js', '!src/ignore.js']. Run the task and confirmdist/ignore.min.js is not created. - Check exit codes: Run
grunt nonexistent— it should exit non-zero. Rungruntwith no default task — it exits 0 with a warning. Adjust CI scripts accordingly. - Node version test: Execute the full build in the project's target Node version (via
nvm,fnm, or CI matrix) to catch plugin incompatibilities early.
When to Stay, When to Migrate
Grunt's multi-task + globbing model remains effective for stable, plugin-heavy pipelines where the team knows the quirks. The declarative files array with expand: true is still one of the clearest ways to express "process every file matching this pattern" without writing loops. However, the combination of config bloat, single-threaded execution, and maintenance-mode status means new projects should start with a modern alternative. For existing codebases, the verification steps above keep the pipeline reliable while you plan a migration.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.