After a little research, I found a workaround. Of course, he feels a little dirty.
My mask class was attached to events (input) and (keyup.backspace):
... host: { '(input)': 'onInputChange($event.target.value)', '(keyup.backspace)': 'onInputChange($event.target.value, true)' } ...
Instead, I became attached to (blur) and (focus) events.
... host: { '(blur)': 'onInputChange($event.target.value)', '(focus)': 'removeMask($event.target.value)' } ...
In focus, I remove the mask, and then blur it, add it back. This ensures that the validator gets the correct value whenever it changes, without masking intervention. Then I changed FormControl to use a numerical validator, not a phone validator, since the value being checked will not be applied to the mask.
phone mask:
@Directive({ selector: '[phoneMask]', host: { '(blur)': 'onInputChange($event.target.value)', '(focus)': 'removeMask($event.target.value)' } }) export class PhoneMask { constructor(public control: NgControl) { } onInputChange(value) { // remove all mask characters (keep only numeric) var newVal = value.replace(/\D/g, ''); // non-digits // set the new value this.control.valueAccessor.writeValue(PhoneMask.applyMask(newVal)); } removeMask(value) { this.control.valueAccessor.writeValue(value.replace(/\D/g, '')); } static applyMask(value: string): string { if (value.length == 0) { value = ''; } else if (value.length <= 3) { value = value.replace(/^(\d{0,3})/, '($1)'); } else if (value.length <= 6) { value = value.replace(/^(\d{0,3})(\d{0,3})/, '($1) $2'); } else { value = value.replace(/^(\d{0,3})(\d{0,3})(.*)/, '($1) $2-$3'); } return value; } }
numeric validator:
export function validateNumeric(control: FormControl) { let regex = /[0-9]+/; return !control.value || regex.test(control.value) ? null : { numeric: { valid: false } }; }