Strict mode is a group of TypeScript checks that you switch on with one line, "strict": true. The two that matter most are noImplicitAny, which refuses values whose type TypeScript cannot work out, and strictNullChecks, which makes null and undefined part of a type instead of something that fits anywhere. Since TypeScript 6.0, and so in 7.0, strict is on by default. An older Vue project with "strict": false in its tsconfig.json gets a long list of errors the day someone removes that line.
The list looks worse than it is. In Vue code most of it comes down to six kinds of error, shown here roughly in the order you are likely to meet them, each with the fix that keeps the check doing its job. None of the fixes uses the non-null assertion !, which tells TypeScript "trust me" and switches the check off for that one value.
Run vue-tsc on TypeScript 6.0
Plain tsc does not read .vue files. vue-tsc does: it turns each component, template included, into TypeScript and runs the compiler over it. For that it needs the compiler's programmatic API, and TypeScript 7.0, the new Go compiler, ships without one. Its release announcement says it plainly: projects using Vue need to keep using TypeScript 6.0 for now. The create-vue 3.23 scaffold, released after 7.0, still installs "typescript": "~6.0.0" next to vue-tsc ^3.3.7.
If you also want TypeScript 7's fast tsc for plain .ts files, the announcement describes installing both side by side through npm aliases, and vue-tsc 3.3.8 and later recognise the setup:
{
"devDependencies": {
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vue-tsc": "^3.3.11"
}
}
npx tsc now runs 7.0, and vue-tsc uses the 6.0 compiler inside @typescript/typescript6, the compatibility package. The two should agree. The announcement says code that compiles cleanly on 6.0 should compile identically on 7.0, as long as 6.0 runs with its stableTypeOrdering flag on (it makes 6.0 order types as 7.0 does) and without an ignoreDeprecations setting.
Turn strict on and run the check
- Delete
"strict": falsefrom the tsconfig that sets it (in a create-vue project, usuallytsconfig.app.json), or change it totrue. - Run
npm run type-checkin a create-vue project, which runsvue-tsc --build. In a project with a singletsconfig.json, runnpx vue-tsc --noEmit.
In a create-vue project, a bare npx vue-tsc --noEmit reads the root tsconfig.json, which only references the other configs, so it checks no files and reports nothing.
Apart from noImplicitAny and strictNullChecks, the flags in the group rarely fire in <script setup> code: strictPropertyInitialization and noImplicitThis, for example, are about classes and this.
One exception is useUnknownInCatchVariables. It types the error in a catch block as unknown, because JavaScript lets you throw anything. So catch (error) { console.log(error.message) } now fails with TS18046: 'error' is of type 'unknown'. Check what you caught before you read it: error instanceof Error ? error.message : String(error).
Every error in this post was printed by vue-tsc 3.3.11.
Template refs that may be null
This component focuses its input once it has mounted:
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
const input = useTemplateRef<HTMLInputElement>('input')
onMounted(() => {
input.value.focus()
})
</script>
<template>
<input ref="input">
</template>
error TS18047: 'input.value' is possibly 'null'.
TypeScript is right. A template ref holds null before the component mounts, after it unmounts, and whenever its element sits inside a v-if that is false. Handle the empty case:
onMounted(() => {
input.value?.focus()
})
?. calls focus() only when there is an element. If the code really cannot go on without it, return early with if (!input.value) return, and TypeScript narrows the type for the lines that follow.
Untyped event handlers and event targets
A handler written without types is the most common noImplicitAny error:
function onInput(event) {
console.log(event.target.value)
}
error TS7006: Parameter 'event' implicitly has an 'any' type.
Adding event: Event fixes that line and reveals two more: 'event.target' is possibly 'null' and Property 'value' does not exist on type 'EventTarget'. An event's target can be any element, or even the window, so TypeScript will not assume an input. Check it:
function onInput(event: Event) {
if (event.target instanceof HTMLInputElement) {
console.log(event.target.value)
}
}
instanceof narrows the type and checks the value at runtime in one step, which an as HTMLInputElement cast does not. For form fields, v-model often removes the handler and the problem with it.
Optional props without defaults
This avatar halves an optional size prop:
<script setup lang="ts">
const props = defineProps<{ name: string, size?: number }>()
const radius = props.size / 2
</script>
error TS18048: 'props.size' is possibly 'undefined'.
The ? makes size optional, so its type is number | undefined. Give it a default. Vue 3.5 lets you do that while destructuring the props:
<script setup lang="ts">
import { computed } from 'vue'
const { name, size = 32 } = defineProps<{ name: string, size?: number }>()
const radius = computed(() => size / 2)
</script>
<template>
<span :style="{ width: `${size}px`, borderRadius: `${radius}px` }">{{ name.slice(0, 1) }}</span>
</template>
size is now a number. Derived values go in computed so they update when the parent passes a new size; Vue keeps destructured props reactive inside it.
Object keys and inject that may find nothing
Two more errors share a cause: you asked for something that might not be there.
The first is an object indexed with a string:
const props = defineProps<{ status: string }>()
const colours = { open: 'green', closed: 'grey' }
const colour = colours[props.status]
error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ open: string; closed: string; }'.
Any string could arrive, and most strings are not keys of colours. The fix is usually to say which strings are allowed, which also improves the component's API:
import { computed } from 'vue'
type Status = 'open' | 'closed'
const props = defineProps<{ status: Status }>()
const colours: Record<Status, string> = { open: 'green', closed: 'grey' }
const colour = computed(() => colours[props.status])
When keys really are open-ended, type the object as Record<string, string> and handle the miss. A create-vue project makes that compulsory: its tsconfig.app.json switches on noUncheckedIndexedAccess, a flag outside strict that types every such lookup as possibly undefined. For a labels object typed that way, labels[key].toUpperCase() fails with Object is possibly 'undefined', so write labels[key]?.toUpperCase() ?? key.
The second is inject. It returns undefined when no ancestor provided the key. So with the UserKey declared below, inject(UserKey) is typed Ref<User> | undefined, and user.value.name fails with 'user' is possibly 'undefined'. Decide what a missing provider means:
- If the component can do without it, pass a default:
inject(UserKey, ref({ name: 'Guest' }))is typedRef<User>. - If it cannot, fail loudly in a composable:
import { computed, inject, type InjectionKey, type Ref } from 'vue'
export interface User { name: string }
export const UserKey: InjectionKey<Ref<User>> = Symbol('user')
export function useUserName() {
const user = inject(UserKey)
if (!user) {
throw new Error('useUserName() needs a user provided by an ancestor')
}
return computed(() => user.value.name)
}
After the throw, TypeScript knows user is defined, inside the computed too.
vue-tsc checks templates with the same rules
This is where strict mode pays for itself in Vue. In this card, user starts as null:
<script setup lang="ts">
import { ref } from 'vue'
interface User { name: string }
const user = ref<User | null>(null)
</script>
<template>
<p>{{ user.name }}</p>
</template>
Without strict, this passes the type check and fails in the browser on first render with TypeError: Cannot read properties of null (reading 'name').
With strict, vue-tsc reports error TS18047: '__VLS_ctx.user' is possibly 'null'. The odd prefix is vue-tsc's internal name for the template's context; the line and column point at your template.
Write <p v-if="user">{{ user.name }}</p> and the error goes, because vue-tsc narrows types inside v-if the way TypeScript narrows them inside an if.
Turn strict on in stages
You do not have to fix everything in one pull request. Individual flags override strict, so start by adding two lines to the compilerOptions you already have:
{
"compilerOptions": {
"strict": true,
"strictNullChecks": false
}
}
The null and undefined errors stay hidden for now, so you can clear the implicit any errors first. They are the quick ones. When they are gone, take on strictNullChecks folder by folder, with a second config that turns it on for the folders you have finished:
{
"extends": "./tsconfig.app.json",
"compilerOptions": { "strictNullChecks": true },
"include": ["env.d.ts", "src/utils/**/*", "src/composables/**/*"]
}
Run npx vue-tsc --noEmit -p tsconfig.strict.json in CI next to your normal check, and add folders to include as you clean them.
One catch: TypeScript also checks every file that an included file imports, so a component in the list pulls in its composables. Work from the bottom up, utilities and composables first, then stores, then components. When the list covers all of src, delete the "strictNullChecks": false line and the second config.