diff --git a/src/string/dedent.spec.ts b/src/string/dedent.spec.ts
new file mode 100644
index 000000000..e7c113125
--- /dev/null
+++ b/src/string/dedent.spec.ts
@@ -0,0 +1,149 @@
+import { describe, expect, it } from 'vitest';
+import { dedent } from './dedent';
+
+describe('dedent', () => {
+ it('removes indentation from a multi-line string', () => {
+ const result = dedent`
+ line 1
+ line 2
+ line 3
+ `;
+ expect(result).toBe('line 1\n line 2\nline 3');
+ });
+
+ it('works as a function', () => {
+ const result = dedent(`
+ line 1
+ line 2
+ `);
+ expect(result).toBe('line 1\nline 2');
+ });
+
+ it('handles interpolation', () => {
+ const value = 'world';
+ const result = dedent`
+ hello
+ ${value}
+ `;
+ expect(result).toBe('hello\nworld');
+ });
+
+ it('removes common indentation with blank lines', () => {
+ const result = dedent`
+ line 1
+
+ line 2
+ `;
+ expect(result).toBe('line 1\n\n line 2');
+ });
+
+ it('removes leading and trailing empty lines', () => {
+ const result = dedent`
+ text
+ `;
+ expect(result).toBe('text');
+ });
+
+ it('handles mixed indentation correctly (closest common indent)', () => {
+ const result = dedent`
+
+
+
+ `;
+ expect(result).toBe('\n \n
');
+ });
+
+ it('does not strip indentation if there is none common', () => {
+ const result = dedent`
+ line 1
+ line 2
+ `;
+ expect(result).toBe(' line 1\nline 2');
+ });
+
+ it('handles lines that do not start with common indent', () => {
+ const result = dedent`
+ indented
+no-indent
+ `;
+ expect(result).toBe(' indented\nno-indent');
+ });
+
+ it('handles string without leading newline', () => {
+ const result = dedent(' hello\n world');
+ expect(result).toBe('hello\nworld');
+ });
+
+ it('handles string without trailing newline', () => {
+ const result = dedent(' hello\n world');
+ expect(result).toBe('hello\nworld');
+ });
+
+ it('handles empty string', () => {
+ const result = dedent('');
+ expect(result).toBe('');
+ });
+
+ it('handles string with only whitespace', () => {
+ const result = dedent(' \n \n ');
+ expect(result).toBe(' ');
+ });
+
+ it('handles single line without indentation', () => {
+ const result = dedent('hello');
+ expect(result).toBe('hello');
+ });
+
+ it('handles lines with content but no leading whitespace', () => {
+ const result = dedent('line1\nline2\nline3');
+ expect(result).toBe('line1\nline2\nline3');
+ });
+
+ it('preserves non-indented content line when common indent exists', () => {
+ const result = dedent(' indented\na');
+ expect(result).toBe(' indented\na');
+ });
+
+ describe('tag composition (TC39 proposal)', () => {
+ it('works with a simple tag function', () => {
+ const upper = (strings: TemplateStringsArray, ...values: unknown[]) => {
+ let result = '';
+ for (let i = 0; i < strings.length; i++) {
+ result += strings[i];
+ if (i < values.length) {
+ result += String(values[i]);
+ }
+ }
+ return result.toUpperCase();
+ };
+
+ const dedentedUpper = dedent(upper) as (strings: TemplateStringsArray, ...values: unknown[]) => string;
+ const result = dedentedUpper`
+ hello
+ world
+ `;
+ expect(result).toBe('HELLO\nWORLD');
+ });
+
+ it('preserves interpolation with tag composition', () => {
+ const identity = (strings: TemplateStringsArray, ...values: unknown[]) => {
+ let result = '';
+ for (let i = 0; i < strings.length; i++) {
+ result += strings[i];
+ if (i < values.length) {
+ result += String(values[i]);
+ }
+ }
+ return result;
+ };
+
+ const dedentedIdentity = dedent(identity) as (strings: TemplateStringsArray, ...values: unknown[]) => string;
+ const name = 'es-toolkit';
+ const result = dedentedIdentity`
+ Welcome to
+ ${name}!
+ `;
+ expect(result).toBe('Welcome to\nes-toolkit!');
+ });
+ });
+});
diff --git a/src/string/dedent.ts b/src/string/dedent.ts
new file mode 100644
index 000000000..4f35a0c16
--- /dev/null
+++ b/src/string/dedent.ts
@@ -0,0 +1,124 @@
+/**
+ * A tag function type that can be used with template literals.
+ */
+type TagFunction = (strings: TemplateStringsArray, ...values: unknown[]) => T;
+
+/**
+ * Removes the common leading indentation from a multi-line string.
+ *
+ * This function can be used as a tagged template literal, a regular function,
+ * or composed with another tag function (TC39 String.dedent proposal).
+ *
+ * @param {TemplateStringsArray} template - The template string array.
+ * @param {unknown[]} args - The values to be interpolated.
+ * @returns {string} - The dedented string.
+ *
+ * @example
+ * dedent`
+ * hello
+ * world
+ * `; // returns "hello\nworld"
+ *
+ * @example
+ * dedent(" hello\n world"); // returns "hello\nworld"
+ *
+ * @example
+ * const html = (s: TemplateStringsArray) => s.join('');
+ * dedent(html)`
+ * Hello
+ * `; // returns "Hello
"
+ */
+export function dedent(template: TemplateStringsArray, ...args: unknown[]): string;
+export function dedent(str: string): string;
+export function dedent(tagFn: TagFunction): TagFunction;
+export function dedent(
+ template: TemplateStringsArray | string | TagFunction,
+ ...args: unknown[]
+): string | TagFunction {
+ if (typeof template === 'function') {
+ const tagFn = template;
+ return function dedentedTag(strings: TemplateStringsArray, ...values: unknown[]): T {
+ const dedentedStrings = dedentTemplateStringsArray(strings);
+ return tagFn(dedentedStrings, ...values);
+ };
+ }
+
+ if (typeof template === 'string') {
+ return processDedent(template);
+ }
+
+ let result = '';
+ for (let i = 0; i < template.length; i++) {
+ result += template[i];
+ if (i < args.length) {
+ result += String(args[i]);
+ }
+ }
+
+ return processDedent(result);
+}
+
+function dedentTemplateStringsArray(strings: TemplateStringsArray): TemplateStringsArray {
+ const joined = strings.join('\x00');
+ const dedented = processDedent(joined);
+ const result = dedented.split('\x00');
+
+ const templateArray = result as unknown as TemplateStringsArray;
+ Object.defineProperty(templateArray, 'raw', {
+ value: result,
+ writable: false,
+ enumerable: false,
+ configurable: false,
+ });
+
+ return templateArray;
+}
+
+function processDedent(input: string): string {
+ const lines = input.split('\n');
+ let commonIndent: string | null = null;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ if (line.trim() === '') {
+ continue;
+ }
+
+ const match = line.match(/^(\s+)/);
+ const indent = match ? match[1] : '';
+
+ if (commonIndent === null) {
+ commonIndent = indent;
+ } else {
+ let j = 0;
+ while (j < commonIndent.length && j < indent.length && commonIndent[j] === indent[j]) {
+ j++;
+ }
+ commonIndent = commonIndent.substring(0, j);
+ }
+ }
+
+ const indentToRemove = commonIndent || '';
+
+ const outputLines: string[] = [];
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ if (line.startsWith(indentToRemove)) {
+ outputLines.push(line.substring(indentToRemove.length));
+ } else {
+ outputLines.push('');
+ }
+ }
+
+ if (outputLines.length > 0 && outputLines[0].trim() === '') {
+ outputLines.shift();
+ }
+
+ if (outputLines.length > 0 && outputLines[outputLines.length - 1].trim() === '') {
+ outputLines.pop();
+ }
+
+ return outputLines.join('\n');
+}
diff --git a/src/string/index.ts b/src/string/index.ts
index e08cd2543..205d95d13 100644
--- a/src/string/index.ts
+++ b/src/string/index.ts
@@ -2,6 +2,7 @@ export { camelCase } from './camelCase.ts';
export { capitalize } from './capitalize.ts';
export { constantCase } from './constantCase.ts';
export { deburr } from './deburr.ts';
+export { dedent } from './dedent.ts';
export { escape } from './escape.ts';
export { escapeRegExp } from './escapeRegExp.ts';
export { kebabCase } from './kebabCase.ts';